# JVM 内存结构

# 类加载器

# 什么是类加载器?

在类加载的过程中的加载阶段中,第一步是获取Class文件的二进制字节流,至于怎么获取,虚拟机没做要求,由开发人员来实现获取二进制字节流的这个动作。实现这个动作的代码模块就叫做“类加载器”。 简而言之,类加载器使用来实现类的加载动作的。

# 类加载器的分类

  • 定义加载器:类加载器LL直接创建了类或接口CC,那么就说LL定义了CC,或者说,LLCC的定义加载器。
  • 初始加载器:当一个类加载器通过双亲委派机制把加载请求委托给其他类加载器,那么发起这个加载请求的类加载器和最终创建该类的类加载器不需要是同一个。即是说,类加载器 LL 创建了 CC ,可能是 LL 通过委托其他类加载器来创建的,可以说LL 导致了 CC 的创建,或者说,LLCC 的初始加载器。

定义加载器是调用了 defineClass() 方法的那个

# 在虚拟机中,如何确定一个类?

在虚拟机运行时,类或接口不仅仅是由它的类的名称来决定,而是有一个值对:全限定名称和它的定义加载器共同确定的。形如<NN, LdL_d>,其中 NN 是类或者接口的名称,LdL_d 是该类的定义加载器。就是说运行时的一个类由类的全限定名和该类的定义加载器确定,以确定类的唯一性

# 类加载器的作用

  1. 用来实现类的加载动作。

  2. 用来确定一个类

    import java.io.*;
    
    public class ClassLoaderTest {
    
        public static void main(String[] args) throws Exception {
    
            ClassLoader loader = new ClassLoader() {
                @Override
                public Class<?> loadClass(String name) throws ClassNotFoundException {
                    try {
                        String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                        InputStream is = getClass().getResourceAsStream(fileName);
                        if(is == null){
                            return super.loadClass(name);
                        }
                        byte[] b = new byte[is.available()];
                        is.read(b);
                        return defineClass(name, b, 0, b.length);
                    } catch(IOException e) {
                        throw new ClassNotFoundException(name);
                    }
                }
            };
    
            Object obj = loader.loadClass("ClassLoaderTest").newInstance();
    
            System.out.println(obj.getClass());
            System.out.println("obj instanceof ClassLoaderTest: " + (obj instanceof ClassLoaderTest));
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30

    输出

    class ClassLoaderTest
    obj instanceof ClassLoaderTest: false
    
    1
    2

# 双亲委派机制

如果一个类的加载器L1L1收到类的加载请求,L1L1不会马上加载这个类,而是先检查这个类有没有被加载过了,如果没有,那么把这个请求委派给父类加载器,每一个层次的类加载器都是如此。因此,所有的加载请求都会最终委派到启动类加载器。只有当父类加载器表示无法完成这个加载请求时(通过抛出ClassNotFoundException异常提醒),子加载器才会尝试自己去加载。

# 系统提供的三种类加载器

# 启动类加载器(bootstrap ClassLoader)

  • 负责加载存放在JAVA_HOME/lib,或者被 -Xbootclasspath 参数所指定的路径中,被虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合就算放在路径中,也是不起作用的)类库加载到虚拟机内存中。
  • 不向外界程序提供直接引用,需要通过双亲委派机制最终调用。

# 拓展类加载器(Extension ClassLoader)

  • 负责加载JAVA_HOME/jre/lib/ext 目录中,或者被java.ext.dirs系统变量指定的路径中的所有类库
  • 可以直接使用拓展类加载器

关于java.ext.dirs 这里有一篇文章可以帮助理解这个变量:传送门 (opens new window)

# 应用程序类加载器(Application ClassLoader)

  • 负责加载用户类路径(ClassPath)上所指定的类库
  • 这个类加载器是ClassLoder中的getSystemClassLoader() 方法的返回值,所以这个类加载器也叫系统类加载器
  • 可以直接使用系统类加载器

各个类加载器之间的关系并非继承,而是组合。

# 各个加载器之间的层次关系

类加载器层次

# 类加载的过程

一个类,从加载到内存中开始,到使用,到卸载出内存,其生命周期大致分为以下五部分。 加载->验证->准备->解析->初始化

# 加载

在加载阶段,虚拟机需要完成以下三件事

  1. 通过类的全限定名获取定义此类的二进制字节流
  2. 将这个字节流中所代表的静态存储结构转化方法区中的运行时数据结构
  3. 在内存中生成一个代表该类的Class对象,作为方法区这个类的各种数据的访问入口。

关于第一点,虚拟机并没有具体要求该二进制字节流去哪里获取、怎么获取,所以就出现了很多种获取二进制字节流的方法。如

  1. 从zip, jar, ear, war包获取。
  2. 从网络中获取,如applet。
  3. 运行时计算生成,如动态代理。
  4. 其他文件生成,如jsp。
  5. 从数据库中读取。

# 数组类的加载

几个概念需要先重点了解一下,方便后面理解数组类的加载过程
元素类型,指数组去掉所有维度之后的类型,如String[][]的元素类型是String。
组件类型,指数组去掉一个维度之后的类型,如Sting[][]的组件类型是String[]。
引用类型,一种对象类型,它的值是指向内存空间的引用,就是地址。 基本数据类型,就是八种基本数据类型,byte, short, int, long, char, boolean, double, float。
数组类由虚拟机直接创建,而不是通过类加载器创建。
但是数组类与类加载器的关系还是很紧密的,数组类的元素类型最终是要靠类加载器来加载。

  • 数组类的创建过程 如果数组的组件类型是引用类型,那么就递归地加载这个组件类型。并且将加载该数组类的元素类型的定义类加载器视为该数组类的定义类加载器。 例如要加载String[][],因为其组件类型是String[],那么此时就该递归地加载String[];加载String[]时,因为其组件类型是String,那么此时就该加载String了;因为String是String[][]的元素类型,那么现在就不用递归下去,而是应该加载String类。
    • 如果数组的组件类型不是引用类型(如int[][]),那么该数组类的类加载器为启动类加载器
    • 数组类的可见性与其组件类型的可见性(即可访问性)一致,如果数组的组件类型不是引用类型,那么数组类的可见性为public。

前面已经说过,对于Class文件,并没有要求一定是要Java源码编译过来的,也可以从其他途径得来。这就产生了一个问题,如果是虚拟机自己编译的,那么,遇到很多编译时期的错误时,虚拟机是可以拒绝编译的。但是,对于从别处来的Class文件,我们不敢肯定其中有没有包含恶意代码,我们也没编译过它,它是否包含编译时的错误,我么无从得知。虚拟机有可能因为载入了恶意二进制字节流,导致系统崩溃。因此,我们需要对二进制字节流进行验证

# 验证

这一阶段的目的是为了保护虚拟机,确保读入的Class文件的字节流包含的信息符合虚拟机的要求,不会危害虚拟机自身的安全。

这一部分内容涉及到Class文件的格式的知识。 大致会验证哪些方面的内容呢?

  1. 文件格式验证,验证字节流是否符合Class文件格式规范
    • 是否以魔数 0xCAFEBABE 开头。
    • 主次版本号是否在当前虚拟机的处理范围之内。
    • 常量池的常量中是否有不被支持的常量类型(检测常量tag标志)
  2. 元数据验证,对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。
    • 这个类是否有父类(除了java.lang.Object)。
    • 这个类是否继承了不能继承的类(被 final 修饰的类,如String)
    • 如果这个类是抽象类,那么它是否实现了其父类或接口中所有要求实现的方法。
    • 类中的字段、方法是否与父类产生矛盾(如覆盖了父类的final字段,或出现不符合规则的方法重载,如方法签名一样,但返回值不一样)
  3. 字节码验证,这个阶段将对类的方法体做校验分析。通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证,主要目的是确保解析动作能正常执行。

验证阶段非常重要,但不是一定必要的。如果我们一段代码,已经被验证过,或者我们能保证其是安全的,我们就可以通过 -Xverify:none 参数来关闭验证,以缩短虚拟机加载的时间。

# 准备

准备阶段开始为类变量分配内存并设置和该类变量的初始值

  • 类变量:被static修饰的变量,是属于类的,而不是属于某一个类的实例对象。
  • 初始值:这里的初始值,是指该变量的数据类型的零值。看下表
数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char '\u0000' reference null
byte (byte)0

给类变量赋予指定值是要等到初始化阶段才执行的。如下面的语句,在准备阶段,虚拟机给i分配内存并赋 int 的零值,即 i=0,然后要等到初始化阶段,i 才被赋 123,即 i=123 。

public static int i = 123;
1

对于ConstantValue属性的字段(我理解为常量),会在准备阶段时就赋予指定值。如下面这句代码,在准备阶段,虚拟机就给 i 分配了内存,并赋予指定值 123。因为在编译阶段,javac 就为 i 生成了ConstantValue属性,然后,在准备阶段,虚拟机根据ConstantValue 将 i 赋值为 123 。

public static final int i = 123
1

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。 在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性,且限于基本类型和String。

# 解析

解析阶段是将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:一组符号,用来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定义到目标的句柄。直接引用与虚拟机的内存布局有关。同一个符号引用,在不同的虚拟机中,转化出来的直接引用一般不同。如果有了直接引用,那引用的目标一定已经在内存中了。

个人理解: 符号引用就是一个类引用了其他类,但是虚拟机在并不知道这些类在内存中的何处,所以先用符号来说明我这个类需要这个辅助类等到了解析阶段,虚拟机知道这些辅助类在内存的何处时,就把这些符号代替成辅助类的内存地址,这些地址就是直接引用。

回顾前文,在准备阶段,虚拟机为类变量分配了内存,并设置了零值。我们说,一般要等到初始化阶段才会给变量赋予指定值。那么在初始化阶段,虚拟机是怎样设置指定值的呢?

# 初始化

在初始化阶段,虚拟机根据开发者制定的主观计划初始化变量和其他资源。(就是我们写的初始化代码)

在这个阶段,有一个叫类构造器<clinit>()方法的东西,初始化其实就是执行这个方法的过程。下面我们来讲讲这个怎么生成的,以及它有什么特点。

# 类构造器<clinit>()方法的生成

  • <clinit>()方法是编译器自动收集类中的所有的类变量的赋值动作和静态语句块的语句合并生成的是针对类变量和静态语句块的。在该方法中,语句的顺序的前后取决于源代码中的顺序。如源代码中语句a在语句b前,那么在<clinit>()方法中自然是先执行a再执行b。
  • 为什么平时写的静态语句块,我没调用它,程序运行时也会执行这段代码,原来是在加载类的时候执行了。
  • 另外,对于静态语句块,我们可以在其中访问定义在静态语句块之前的变量,但是定义在静态语句块之后的变量,我们只能修改,不能访问。 以下面的代码为例,看注释
public class StaticInit{
    // 没进语句块之前,此时 i = 0 (零值)
    static {
        // 对于这一句,i 被修改为 100。
        i = 100;
        // 编译器在这里会报错:“非法前向引用”
        System.out.println(i);
    }

    // 不论前面的代码将 i 更改为多少,等到了这一句, i 都会变成20
    // 换句话说就是前面的修改是无意义的,因此可以随便修改。
    public static int i = 20;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# <clinit>()方法特点

  • <clinit>()方法与类的构造函数不同。虚拟机会保证子类的<clinit>()方法执行之前,其父类的<clinit>()方法已经执行。

因此虚拟机执行的第一个<clinit>()方法是java.lang.Object的

  • 由于父类的<clinit>()方法先于子类的<clinit>()方法执行执行,所以父类的静态语句块要优于子类执行
  • 如果一个类没有静态语句块,那么可以不为这个类生成<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个类去执行这个类的<clinit>()方法,其它线程只能阻塞,直到执行<clinit>()方法的线程执行完毕。
上次更新: 2023/10/15